import { ff } from "@humansignal/core";
import { inject } from "mobx-react";
import { destroy, getRoot, getType, types } from "mobx-state-tree";

import ImageView from "../../../components/ImageView/ImageView";
import { customTypes } from "../../../core/CustomTypes";
import Registry from "../../../core/Registry";
import { AnnotationMixin } from "../../../mixins/AnnotationMixin";
import { IsReadyWithDepsMixin } from "../../../mixins/IsReadyMixin";
import { BrushRegionModel } from "../../../regions/BrushRegion";
import { EllipseRegionModel } from "../../../regions/EllipseRegion";
import { KeyPointRegionModel } from "../../../regions/KeyPointRegion";
import { PolygonRegionModel } from "../../../regions/PolygonRegion";
import { VectorRegionModel } from "../../../regions/VectorRegion";
import { RectRegionModel } from "../../../regions/RectRegion";
import * as Tools from "../../../tools";
import ToolsManager from "../../../tools/Manager";
import { parseValue } from "../../../utils/data";
import {
  FF_DEV_3377,
  FF_DEV_3391,
  FF_DEV_3793,
  FF_LSDV_4583,
  FF_LSDV_4583_6,
  FF_ZOOM_OPTIM,
  isFF,
} from "../../../utils/feature-flags";
import { guidGenerator } from "../../../utils/unique";
import { clamp, isDefined } from "../../../utils/utilities";
import ObjectBase from "../Base";
import { DrawingRegion } from "./DrawingRegion";
import { ImageEntityMixin } from "./ImageEntityMixin";
import { ImageSelection } from "./ImageSelection";
import { RELATIVE_STAGE_HEIGHT, RELATIVE_STAGE_WIDTH, SNAP_TO_PIXEL_MODE } from "../../../components/ImageView/Image";
import MultiItemObjectBase from "../MultiItemObjectBase";

const IMAGE_PRELOAD_COUNT = 3;
const ZOOM_INTENSITY = 0.009;
const MIN_ZOOM = 0.1;
const MAX_ZOOM = 100;
const MAX_ZOOM_CHANGE_PER_EVENT = 0.3; // Maximum zoom change per wheel event (30%)

/**
 * The `Image` tag shows an image on the page. Use for all image annotation tasks to display an image on the labeling interface.
 *
 * Use with the following data types: images.
 *
 * When you annotate image regions with this tag, the annotations are saved as percentages of the original size of the image, from 0-100.
 *
 * @example
 * <!--Labeling configuration to display an image on the labeling interface-->
 * <View>
 *   <!-- Retrieve the image url from the url field in JSON or column in CSV -->
 *   <Image name="image" value="$url" rotateControl="true" zoomControl="true"></Image>
 * </View>
 *
 * @example
 * <!--Labeling configuration to perform multi-image segmentation-->
 *
 * <View>
 *   <!-- Retrieve the image url from the url field in JSON or column in CSV -->
 *   <Image name="image" valueList="$images" rotateControl="true" zoomControl="true"></Image>
 * </View>
 * <!-- {
 *   "data": {
 *     "images": [
 *       "https://images.unsplash.com/photo-1556740734-7f3a7d7f0f9c?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1950&q=80",
 *       "https://images.unsplash.com/photo-1556740734-7f3a7d7f0f9c?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=1950&q=80",
 *     ]
 *   }
 * } -->
 * @name Image
 * @meta_title Image Tags for Images
 * @meta_description Customize Label Studio with the Image tag to annotate images for computer vision machine learning and data science projects.
 * @param {string} name                       - Name of the element
 * @param {string} value                      - Data field containing a path or URL to the image
 * @param {string} [valueList]                - References a variable that holds a list of image URLs. For an example, see the [Multi-Page Document Annotation](/templates/multi-page-document-annotation) template.
 * @param {boolean} [smoothing]               - Enable smoothing, by default it uses user settings
 * @param {string=} [width=100%]              - Image width
 * @param {string=} [maxWidth=750px]          - Maximum image width
 * @param {boolean=} [zoom=false]             - Enable zooming an image with the mouse wheel
 * @param {boolean=} [negativeZoom=false]     - Enable zooming out an image
 * @param {float=} [zoomBy=1.1]               - Scale factor
 * @param {boolean=} [grid=false]             - Whether to show a grid
 * @param {number=} [gridSize=30]             - Specify size of the grid
 * @param {string=} [gridColor=#EEEEF4]       - Color of the grid in hex, opacity is 0.15
 * @param {boolean} [zoomControl=false]       - Show zoom controls in toolbar
 * @param {boolean} [brightnessControl=false] - Show brightness control in toolbar
 * @param {boolean} [contrastControl=false]   - Show contrast control in toolbar
 * @param {boolean} [rotateControl=false]     - Show rotate control in toolbar
 * @param {boolean} [crosshair=false]         - Show crosshair cursor
 * @param {left|center|right} [horizontalAlignment=left]      - Where to align image horizontally. Can be one of "left", "center", or "right"
 * @param {top|center|bottom} [verticalAlignment=top]         - Where to align image vertically. Can be one of "top", "center", or "bottom"
 * @param {auto|original|fit} [defaultZoom=fit]               - Specify the initial zoom of the image within the viewport while preserving its ratio. Can be one of "auto", "original", or "fit"
 * @param {none|anonymous|use-credentials} [crossOrigin=none] - Configures CORS cross domain behavior for this image, either "none", "anonymous", or "use-credentials", similar to [DOM `img` crossOrigin property](https://developer.mozilla.org/en-US/docs/Web/API/HTMLImageElement/crossOrigin).
 */
const TagAttrs = types.model({
  value: types.maybeNull(types.string),
  valuelist: types.maybeNull(types.string),
  resize: types.maybeNull(types.number),
  width: types.optional(types.string, "100%"),
  height: types.maybeNull(types.string),
  maxwidth: types.optional(types.string, "100%"),
  maxheight: types.optional(types.string, "calc(100vh - 194px)"),
  smoothing: types.maybeNull(types.boolean),

  // rulers: types.optional(types.boolean, true),
  grid: types.optional(types.boolean, false),
  gridsize: types.optional(types.string, "30"),
  gridcolor: types.optional(customTypes.color, "#EEEEF4"),

  zoom: types.optional(types.boolean, true),
  negativezoom: types.optional(types.boolean, false),
  zoomby: types.optional(types.string, "1.1"),

  showlabels: types.optional(types.boolean, false),

  zoomcontrol: types.optional(types.boolean, true),
  brightnesscontrol: types.optional(types.boolean, false),
  contrastcontrol: types.optional(types.boolean, false),
  rotatecontrol: types.optional(types.boolean, false),
  crosshair: types.optional(types.boolean, false),
  selectioncontrol: types.optional(types.boolean, true),

  // this property is just to turn lazyload off to e2e tests
  lazyoff: types.optional(types.boolean, false),

  horizontalalignment: types.optional(types.enumeration(["left", "center", "right"]), "left"),
  verticalalignment: types.optional(types.enumeration(["top", "center", "bottom"]), "top"),
  defaultzoom: types.optional(types.enumeration(["auto", "original", "fit"]), "fit"),

  crossorigin: types.optional(types.enumeration(["none", "anonymous", "use-credentials"]), "none"),
});

const IMAGE_CONSTANTS = {
  rectangleModel: "RectangleModel",
  rectangleLabelsModel: "RectangleLabelsModel",
  ellipseModel: "EllipseModel",
  ellipseLabelsModel: "EllipseLabelsModel",
  brushLabelsModel: "BrushLabelsModel",
  rectanglelabels: "rectanglelabels",
  keypointlabels: "keypointlabels",
  polygonlabels: "polygonlabels",
  vectorlabels: "vectorlabels",
  brushlabels: "brushlabels",
  bitmaskModel: "BitmaskModel",
  bitmasklabels: "bitmasklabels",
  brushModel: "BrushModel",
  ellipselabels: "ellipselabels",
};

const Model = types
  .model({
    type: "image",

    // tools: types.array(BaseTool),

    sizeUpdated: types.optional(types.boolean, false),

    /**
     * Cursor coordinates
     */
    cursorPositionX: types.optional(types.number, 0),
    cursorPositionY: types.optional(types.number, 0),

    brushControl: types.optional(types.string, "brush"),

    brushStrokeWidth: types.optional(types.number, 15),

    /**
     * Mode
     * brush for Image Segmentation
     * eraser for Image Segmentation
     */
    mode: types.optional(types.enumeration(["drawing", "viewing", "brush", "eraser"]), "viewing"),

    regions: types.array(
      types.union(
        BrushRegionModel,
        RectRegionModel,
        EllipseRegionModel,
        PolygonRegionModel,
        VectorRegionModel,
        KeyPointRegionModel,
      ),
      [],
    ),

    drawingRegion: types.optional(DrawingRegion, null),
    selectionArea: types.optional(ImageSelection, { start: null, end: null }),
  })
  .volatile(() => ({
    currentImage: undefined,
    supportSuggestions: true,
  }))
  .views((self) => ({
    get store() {
      return getRoot(self);
    },

    get multiImage() {
      return !!self.isMultiItem;
    },

    // an alias of currentImage to make an interface reusable
    get currentItemIndex() {
      return self.currentImage;
    },

    get parsedValue() {
      return parseValue(self.value, self.store.task.dataObj);
    },

    get parsedValueList() {
      return parseValue(self.valuelist, self.store.task.dataObj);
    },

    get currentSrc() {
      return self.currentImageEntity.src;
    },

    get usedValue() {
      return self.multiImage ? self.valuelist : self.value;
    },

    get images() {
      const value = self.parsedValue;

      if (!value) return [];
      if (Array.isArray(value)) return value;
      return [value];
    },

    /**
     * @return {boolean}
     */
    get hasStates() {
      const states = self.states();

      return states && states.length > 0;
    },

    get selectedRegions() {
      return self.regs.filter((region) => region.inSelection);
    },

    get selectedRegionsBBox() {
      let bboxCoords;

      self.selectedRegions.forEach((region) => {
        const regionBBox = region.bboxCoords;

        if (!regionBBox) return;

        if (bboxCoords) {
          bboxCoords = {
            left: Math.min(regionBBox?.left, bboxCoords.left),
            top: Math.min(regionBBox?.top, bboxCoords.top),
            right: Math.max(regionBBox?.right, bboxCoords.right),
            bottom: Math.max(regionBBox?.bottom, bboxCoords.bottom),
          };
        } else {
          bboxCoords = regionBBox;
        }
      });
      return bboxCoords;
    },

    get regionsInSelectionArea() {
      return self.regs.filter((region) => region.isInSelectionArea);
    },

    get selectedShape() {
      return self.regs.find((r) => r.selected);
    },

    get suggestions() {
      return self.annotation?.regionStore.suggestions.filter((r) => r.object === self) || [];
    },

    get useTransformer() {
      return self.getToolsManager().findSelectedTool()?.useTransformer === true;
    },

    get stageTranslate() {
      const { stageWidth: width, stageHeight: height } = self;

      return {
        0: { x: 0, y: 0 },
        90: { x: 0, y: height },
        180: { x: width, y: height },
        270: { x: width, y: 0 },
      }[self.rotation];
    },

    get stageScale() {
      return self.zoomScale;
    },

    get layerZoomScalePosition() {
      return {
        scaleX: self.zoomScale,
        scaleY: self.zoomScale,
        x: self.zoomingPositionX + self.alignmentOffset.x,
        y: self.zoomingPositionY + self.alignmentOffset.y,
      };
    },

    get hasTools() {
      return !!self.getToolsManager().allTools()?.length;
    },

    get imageCrossOrigin() {
      const value = self.crossorigin.toLowerCase();

      if (!value || value === "none") return "anonymous";

      return value;
    },

    get fillerHeight() {
      const { naturalWidth, naturalHeight } = self;

      return self.isSideways ? `${(naturalWidth / naturalHeight) * 100}%` : `${(naturalHeight / naturalWidth) * 100}%`;
    },

    get zoomedPixelSize() {
      const { naturalWidth, naturalHeight } = self;

      if (isFF(FF_DEV_3793)) {
        return {
          x: 100 / naturalWidth,
          y: 100 / naturalHeight,
        };
      }

      return {
        x: self.stageWidth / naturalWidth,
        y: self.stageHeight / naturalHeight,
      };
    },

    isSamePixel({ x: x1, y: y1 }, { x: x2, y: y2 }) {
      const zoomedPixelSizeX = self.zoomedPixelSize.x;
      const zoomedPixelSizeY = self.zoomedPixelSize.y;

      return Math.abs(x1 - x2) < zoomedPixelSizeX / 2 && Math.abs(y1 - y2) < zoomedPixelSizeY / 2;
    },

    snapPointToPixel({ x, y }, snapMode = SNAP_TO_PIXEL_MODE.EDGE) {
      const zoomedPixelSizeX = self.zoomedPixelSize.x;
      const zoomedPixelSizeY = self.zoomedPixelSize.y;

      switch (snapMode) {
        case SNAP_TO_PIXEL_MODE.EDGE: {
          return {
            x: Math.round(x / zoomedPixelSizeX) * zoomedPixelSizeX,
            y: Math.round(y / zoomedPixelSizeY) * zoomedPixelSizeY,
          };
        }
        case SNAP_TO_PIXEL_MODE.CENTER: {
          return {
            x: Math.floor(x / zoomedPixelSizeX) * zoomedPixelSizeX + zoomedPixelSizeX / 2,
            y: Math.floor(y / zoomedPixelSizeY) * zoomedPixelSizeY + zoomedPixelSizeY / 2,
          };
        }
      }
    },

    createSerializedResult(region, value) {
      const index = region.item_index ?? 0;
      const currentImageEntity = self.findImageEntity(index);

      const imageDimension = {
        original_width: currentImageEntity.naturalWidth,
        original_height: currentImageEntity.naturalHeight,
        image_rotation: currentImageEntity.rotation,
      };

      if (self.multiImage && isDefined(index)) {
        imageDimension.item_index = index;
      }

      // We're using raw region result instead of calulated one when
      // the image data is not available (image is not yet loaded)
      // As the serialization also happens during region creation,
      // we have to forsee this scenario and avoid using raw result
      // as it can only be present for already created (submitter) regions
      const useRawResult = !currentImageEntity.imageLoaded && isDefined(region._rawResult);

      return useRawResult
        ? structuredClone(region._rawResult)
        : {
            ...imageDimension,
            value,
          };
    },

    /**
     * @return {object}
     */
    states() {
      return self.annotation.toNames.get(self.name);
    },

    activeStates() {
      const states = self.states();

      return states && states.filter((s) => s.isSelected && s.type.includes("labels"));
    },

    controlButton() {
      const names = self.states();

      if (!names || names.length === 0) return;

      let returnedControl = names[0];

      names.forEach((item) => {
        if (
          item.type === IMAGE_CONSTANTS.rectanglelabels ||
          item.type === IMAGE_CONSTANTS.brushlabels ||
          item.type === IMAGE_CONSTANTS.bitmasklabels ||
          item.type === IMAGE_CONSTANTS.ellipselabels
        ) {
          returnedControl = item;
        }
      });

      return returnedControl;
    },

    get controlButtonType() {
      const name = self.controlButton();

      return getType(name).name;
    },

    get isSideways() {
      return (self.rotation + 360) % 180 === 90;
    },

    get stageComponentSize() {
      if (self.isSideways) {
        return {
          width: self.stageHeight,
          height: self.stageWidth,
        };
      }
      return {
        width: self.stageWidth,
        height: self.stageHeight,
      };
    },

    get canvasSize() {
      if (self.isSideways) {
        return {
          width: isFF(FF_DEV_3377)
            ? self.naturalHeight * self.stageZoomX
            : Math.round(self.naturalHeight * self.stageZoomX),
          height: isFF(FF_DEV_3377)
            ? self.naturalWidth * self.stageZoomY
            : Math.round(self.naturalWidth * self.stageZoomY),
        };
      }

      return {
        width: isFF(FF_DEV_3377)
          ? self.naturalWidth * self.stageZoomX
          : Math.round(self.naturalWidth * self.stageZoomX),
        height: isFF(FF_DEV_3377)
          ? self.naturalHeight * self.stageZoomY
          : Math.round(self.naturalHeight * self.stageZoomY),
      };
    },

    get alignmentOffset() {
      const offset = { x: 0, y: 0 };

      if (isFF(FF_ZOOM_OPTIM)) {
        switch (self.horizontalalignment) {
          case "center": {
            offset.x = (self.containerWidth - self.canvasSize.width) / 2;
            break;
          }
          case "right": {
            offset.x = self.containerWidth - self.canvasSize.width;
            break;
          }
        }
        switch (self.verticalalignment) {
          case "center": {
            offset.y = (self.containerHeight - self.canvasSize.height) / 2;
            break;
          }
          case "bottom": {
            offset.y = self.containerHeight - self.canvasSize.height;
            break;
          }
        }
      }
      return offset;
    },

    get zoomBy() {
      return Number.parseFloat(self.zoomby);
    },
    get isDrawing() {
      return !!self.drawingRegion;
    },

    get imageTransform() {
      const imgStyle = {
        // scale transform leaves gaps on image border, so much better to change image sizes
        width: `${self.stageWidth * self.zoomScale}px`,
        height: `${self.stageHeight * self.zoomScale}px`,
        transformOrigin: "left top",
        // We should always set some transform to make the image rendering in the same way all the time
        transform: "translate3d(0,0,0)",
        filter: `brightness(${self.brightnessGrade}%) contrast(${self.contrastGrade}%)`,
      };
      const imgTransform = [];

      if (self.zoomScale !== 1) {
        const { zoomingPositionX = 0, zoomingPositionY = 0 } = self;

        imgTransform.push(`translate3d(${zoomingPositionX}px,${zoomingPositionY}px, 0)`);
      }

      if (self.rotation) {
        const translate = {
          90: "0, -100%",
          180: "-100%, -100%",
          270: "-100%, 0",
        };

        // there is a top left origin already set for zoom; so translate+rotate
        imgTransform.push(`rotate(${self.rotation}deg)`);
        imgTransform.push(`translate(${translate[self.rotation] || "0, 0"})`);
      }

      if (imgTransform?.length > 0) {
        imgStyle.transform = imgTransform.join(" ");
      }
      return imgStyle;
    },

    get maxScale() {
      return self.isSideways
        ? Math.min(self.containerWidth / self.naturalHeight, self.containerHeight / self.naturalWidth)
        : Math.min(self.containerWidth / self.naturalWidth, self.containerHeight / self.naturalHeight);
    },

    get coverScale() {
      return self.isSideways
        ? Math.max(self.containerWidth / self.naturalHeight, self.containerHeight / self.naturalWidth)
        : Math.max(self.containerWidth / self.naturalWidth, self.containerHeight / self.naturalHeight);
    },

    get viewPortBBoxCoords() {
      let width = self.canvasSize.width / self.zoomScale;
      let height = self.canvasSize.height / self.zoomScale;
      const leftOffset = -self.zoomingPositionX / self.zoomScale;
      const topOffset = -self.zoomingPositionY / self.zoomScale;
      const rightOffset = self.stageComponentSize.width - (leftOffset + width);
      const bottomOffset = self.stageComponentSize.height - (topOffset + height);
      const offsets = [leftOffset, topOffset, rightOffset, bottomOffset];

      if (self.isSideways) {
        [width, height] = [height, width];
      }
      if (self.rotation) {
        const rotateCount = (self.rotation / 90) % 4;

        for (let k = 0; k < rotateCount; k++) {
          offsets.push(offsets.shift());
        }
      }
      const left = offsets[0];
      const top = offsets[1];

      return {
        left,
        top,
        right: left + width,
        bottom: top + height,
        width,
        height,
      };
    },
  }))
  .volatile((self) => ({
    manager: null,
  }))
  // actions for the tools
  .actions((self) => {
    const manager = ToolsManager.getInstance({ name: self.name });
    const env = { manager, control: self, object: self };

    function createImageEntities() {
      if (!self.store.task) return;

      // Clear existing entities to prevent duplicates from React StrictMode double mounting
      self.imageEntities.clear();

      const parsedValue = self.multiImage ? self.parsedValueList : self.parsedValue;
      const idPostfix = self.annotation ? `@${self.annotation.id}` : "";

      if (Array.isArray(parsedValue)) {
        parsedValue.forEach((src, index) => {
          self.imageEntities.push({
            id: `${self.name}#${index}${idPostfix}`,
            src,
            index,
          });
        });
      } else {
        self.imageEntities.push({
          id: `${self.name}#0${idPostfix}`,
          src: parsedValue,
          index: 0,
        });
      }

      self.setCurrentImage(0);
    }

    function afterAttach() {
      if (ff.isActive(FF_DEV_3391) && !self.annotation) {
        return;
      }
      if (self.selectioncontrol) manager.addTool("MoveTool", Tools.Selection.create({}, env), "MoveTool");

      if (self.zoomcontrol) manager.addTool("ZoomPanTool", Tools.Zoom.create({}, env), "ZoomPanTool");

      if (self.brightnesscontrol) manager.addTool("BrightnessTool", Tools.Brightness.create({}, env), "BrightnessTool");

      if (self.contrastcontrol) manager.addTool("ContrastTool", Tools.Contrast.create({}, env), "ContrastTool");

      if (self.rotatecontrol) manager.addTool("RotateTool", Tools.Rotate.create({}, env), "RotateTool");

      createImageEntities();
    }

    function afterResultCreated(region) {
      if (!region) return;
      if (region.classification) return;
      if (!self.multiImage) return;

      region.setItemIndex?.(self.currentImage);
    }

    function getToolsManager() {
      return manager;
    }

    return {
      afterAttach,
      getToolsManager,
      afterResultCreated,
    };
  })
  .extend((self) => {
    let skipInteractions = false;

    return {
      views: {
        getSkipInteractions() {
          if (isFF(FF_ZOOM_OPTIM)) {
            if (skipInteractions) return true;

            const isLinkingMode = self.annotation.isLinkingMode;

            if (isLinkingMode) return false;

            const manager = self.getToolsManager();
            const tool = manager.findSelectedTool();
            const canInteractWithRegions = tool?.canInteractWithRegions;

            return !canInteractWithRegions;
          }
          const manager = self.getToolsManager();

          const isPanning = manager.findSelectedTool()?.toolName === "ZoomPanTool";

          return skipInteractions || isPanning;
        },
        get smoothingEnabled() {
          const names = self.annotation?.names;

          if (!names) return self.smoothing;

          const hasBitmask = Array.from(names.values()).some(({ type }) => {
            return type.includes("bitmask");
          });
          if (hasBitmask) return false;
          return self.smoothing;
        },
      },
      actions: {
        setSkipInteractions(value) {
          skipInteractions = value;
        },
        updateSkipInteractions(e) {
          const currentTool = self.getToolsManager().findSelectedTool();

          if (currentTool?.shouldSkipInteractions) {
            return self.setSkipInteractions(currentTool.shouldSkipInteractions(e));
          }
          self.setSkipInteractions(e.evt && (e.evt.metaKey || e.evt.ctrlKey));
        },
      },
    };
  })
  .actions((self) => ({
    freezeHistory() {
      //self.annotation.history.freeze();
    },

    afterRegionSelected(region) {
      if (self.multiImage) {
        self.setCurrentImage(region.item_index);
      }
    },

    createDrawingRegion(areaValue, resultValue, control, dynamic) {
      const controlTag = self.annotation.names.get(control.name);

      const result = {
        from_name: controlTag,
        to_name: self,
        type: control.resultType,
        value: resultValue,
      };

      const areaRaw = {
        id: guidGenerator(),
        object: self,
        ...areaValue,
        results: [result],
        dynamic,
        item_index: self.currentImage,
      };

      self.drawingRegion = areaRaw;
      return self.drawingRegion;
    },

    deleteDrawingRegion() {
      const { drawingRegion } = self;

      if (!drawingRegion) return;
      self.drawingRegion = null;
      destroy(drawingRegion);
    },

    setSelectionStart(point) {
      self.selectionArea.setStart(point);
    },
    setSelectionEnd(point) {
      self.selectionArea.setEnd(point);
    },
    resetSelection() {
      self.selectionArea.setStart(null);
      self.selectionArea.setEnd(null);
    },

    updateBrushControl(arg) {
      self.brushControl = arg;
    },

    updateBrushStrokeWidth(arg) {
      self.brushStrokeWidth = arg;
    },

    /**
     * Update brightnessGrade of Image
     * @param {number} value
     */
    setBrightnessGrade(value) {
      self.brightnessGrade = value;
    },

    setContrastGrade(value) {
      self.contrastGrade = value;
    },

    setGridSize(value) {
      self.gridsize = String(value);
    },

    // an alias of setCurrentImage for making an interface reusable
    setCurrentItem(index = 0) {
      self.setCurrentImage(index);
    },

    setCurrentImage(index = 0) {
      index = index ?? 0;
      if (index === self.currentImage) return;

      self.currentImage = index;
      self.currentImageEntity = self.findImageEntity(index);
      if (isFF(FF_LSDV_4583_6)) self.preloadImages();
    },

    preloadImages() {
      self.currentImageEntity.setImageLoaded(false);
      self.currentImageEntity.preload();

      if (self.multiImage) {
        const [currentIndex, length] = [self.currentImage, self.imageEntities.length];
        const prevSliceIndex = clamp(currentIndex - IMAGE_PRELOAD_COUNT, 0, currentIndex);
        const nextSliceIndex = clamp(currentIndex + 1 + IMAGE_PRELOAD_COUNT, currentIndex, length - 1);

        const images = [
          ...self.imageEntities.slice(prevSliceIndex, currentIndex),
          ...self.imageEntities.slice(currentIndex + 1, nextSliceIndex),
        ];

        images.forEach((imageEntity) => {
          imageEntity.preload();
        });
      }
    },

    /**
     * Set pointer of X and Y
     */
    setPointerPosition({ x, y }) {
      self.freezeHistory();
      self.cursorPositionX = x;
      self.cursorPositionY = y;
    },

    /**
     * Set zoom
     */
    setZoom(scale) {
      scale = clamp(scale, 1, Number.POSITIVE_INFINITY);
      self.currentZoom = scale;

      // cool comment about all this stuff
      const maxScale = self.maxScale;
      const coverScale = self.coverScale;

      if (maxScale > 1) {
        // image < container
        if (scale < maxScale) {
          // scale = 1 or before stage size is max
          self.stageZoom = scale; // scale stage
          self.zoomScale = 1; // don't scale image
        } else {
          self.stageZoom = maxScale; // scale stage to max
          self.zoomScale = scale / maxScale; // scale image for the rest scale
        }
      } else {
        // image > container
        if (scale > maxScale) {
          // scale = 1 or any other zoom bigger then viewport
          self.stageZoom = maxScale; // stage squizzed
          self.zoomScale = scale; // scale image for the rest scale : scale image usually
        } else {
          // negative zoom bigger than image negative scale
          self.stageZoom = scale; // squize stage more
          self.zoomScale = 1; // don't scale image
        }
      }

      if (self.zoomScale > 1) {
        // zoomScale scales image above maxScale, so scale the rest of stage the same way
        const z = Math.min(maxScale * self.zoomScale, coverScale);

        if (self.containerWidth / self.naturalWidth > self.containerHeight / self.naturalHeight) {
          self.stageZoomX = z;
          self.stageZoomY = self.stageZoom;
        } else {
          self.stageZoomX = self.stageZoom;
          self.stageZoomY = z;
        }
      } else {
        self.stageZoomX = self.stageZoom;
        self.stageZoomY = self.stageZoom;
      }
    },

    updateImageAfterZoom() {
      const { stageWidth, stageHeight } = self;

      self._recalculateImageParams();

      if (stageWidth !== self.stageWidth || stageHeight !== self.stageHeight) {
        self._updateRegionsSizes({
          width: self.stageWidth,
          height: self.stageHeight,
          naturalWidth: self.naturalWidth,
          naturalHeight: self.naturalHeight,
        });
      }
    },

    setZoomPosition(x, y) {
      const [width, height] = isFF(FF_DEV_3377)
        ? [self.canvasSize.width, self.canvasSize.height]
        : [self.containerWidth, self.containerHeight];

      const [minX, minY] = [
        width - self.stageComponentSize.width * self.zoomScale,
        height - self.stageComponentSize.height * self.zoomScale,
      ];

      self.zoomingPositionX = clamp(x, minX, 0);
      self.zoomingPositionY = clamp(y, minY, 0);
    },

    resetZoomPositionToCenter() {
      const { stageComponentSize, zoomScale } = self;
      const { width, height } = stageComponentSize;

      const [containerWidth, containerHeight] = isFF(FF_DEV_3377)
        ? [self.canvasSize.width, self.canvasSize.height]
        : [self.containerWidth, self.containerHeight];

      self.setZoomPosition((containerWidth - width * zoomScale) / 2, (containerHeight - height * zoomScale) / 2);
    },

    sizeToFit() {
      const { maxScale } = self;

      self.defaultzoom = "fit";
      self.setZoom(maxScale);
      self.updateImageAfterZoom();
      self.resetZoomPositionToCenter();
    },

    sizeToOriginal() {
      const { maxScale } = self;

      self.defaultzoom = "original";
      self.setZoom(maxScale > 1 ? 1 : 1 / maxScale);
      self.updateImageAfterZoom();
      self.resetZoomPositionToCenter();
    },

    sizeToAuto() {
      self.defaultzoom = "auto";
      self.setZoom(1);
      self.updateImageAfterZoom();
      self.resetZoomPositionToCenter();
    },

    getInertialZoom(val) {
      const invert = getRoot(self).settings.invertedZoom ? 1 : -1;

      // Invert the delta value so that:
      // - Pinch out (positive deltaY) zooms in
      // - Pinch in (negative deltaY) zooms out
      // - Scroll up (positive deltaY) zooms in
      // - Scroll down (negative deltaY) zooms out
      const invertedVal = val * invert;

      // Calculate the zoom change using exponential formula
      // This provides smooth zooming for both mouse wheel and trackpad pinch
      const zoomChange = Math.exp(invertedVal * ZOOM_INTENSITY);

      // Limit the maximum zoom change per event to prevent aggressive zooming
      // This prevents users from accidentally zooming too far with a single wheel event
      const limitedZoomChange = Math.max(
        1 - MAX_ZOOM_CHANGE_PER_EVENT,
        Math.min(1 + MAX_ZOOM_CHANGE_PER_EVENT, zoomChange),
      );

      return clamp(self.currentZoom * limitedZoomChange, MIN_ZOOM, MAX_ZOOM);
    },

    /**
     * Handle zoom events from mouse wheel or trackpad pinch
     * Unified smooth zoom behavior that works well for both input methods
     * @param {number} val - The delta value from the wheel event
     * @param {Object} mouseRelativePos - The mouse position relative to the canvas
     */
    handleZoom(
      val,
      mouseRelativePos = { x: self.canvasSize.width / 2, y: self.canvasSize.height / 2 },
      isEvent = false,
    ) {
      if (val) {
        const zoomScale = isEvent
          ? self.getInertialZoom(val)
          : val > 0
            ? self.currentZoom * self.zoomBy
            : self.currentZoom / self.zoomBy;

        // Handle negative zoom restrictions
        if (self.negativezoom !== true && zoomScale <= 1) {
          self.setZoom(1);
          self.setZoomPosition(0, 0);
          self.updateImageAfterZoom();
          return;
        }

        // Handle zoom out to fit or smaller
        if (zoomScale <= 1) {
          self.setZoom(zoomScale);
          self.setZoomPosition(0, 0);
          self.updateImageAfterZoom();
          return;
        }

        // Zoom to point (mouse position) - keeps the point under the cursor in the same position
        let stageScale = self.zoomScale;

        const mouseAbsolutePos = {
          x: (mouseRelativePos.x - self.zoomingPositionX) / stageScale,
          y: (mouseRelativePos.y - self.zoomingPositionY) / stageScale,
        };

        self.setZoom(zoomScale);

        stageScale = self.zoomScale;

        const zoomingPosition = {
          x: -(mouseAbsolutePos.x - mouseRelativePos.x / stageScale) * stageScale,
          y: -(mouseAbsolutePos.y - mouseRelativePos.y / stageScale) * stageScale,
        };

        self.setZoomPosition(zoomingPosition.x, zoomingPosition.y);
        self.updateImageAfterZoom();
      }
    },

    /**
     * Set mode of Image (drawing and viewing)
     * @param {string} mode
     */
    setMode(mode) {
      self.mode = mode;
    },

    setImageRef(ref) {
      self.imageRef = ref;
    },

    setContainerRef(ref) {
      self.containerRef = ref;
    },

    setStageRef(ref) {
      self.stageRef = ref;

      const currentTool = self.getToolsManager().findSelectedTool();

      currentTool?.updateCursor?.();
    },

    setOverlayRef(ref) {
      self.overlayRef = ref;
    },

    // @todo remove
    setSelected() {
      // self.selectedShape = shape;
    },

    rotate(degree = -90) {
      self.rotation = (self.rotation + degree + 360) % 360;

      let ratioK = 1 / self.stageRatio;

      if (self.isSideways) {
        self.stageRatio = self.naturalWidth / self.naturalHeight;
      } else {
        self.stageRatio = 1;
      }
      ratioK = ratioK * self.stageRatio;

      self.setZoom(self.currentZoom);

      if (degree === -90) {
        this.setZoomPosition(
          self.zoomingPositionY * ratioK,
          self.stageComponentSize.height -
            self.zoomingPositionX * ratioK -
            self.stageComponentSize.height * self.zoomScale,
        );
      }
      if (degree === 90) {
        this.setZoomPosition(
          self.stageComponentSize.width -
            self.zoomingPositionY * ratioK -
            self.stageComponentSize.width * self.zoomScale,
          self.zoomingPositionX * ratioK,
        );
      }

      self.updateImageAfterZoom();
    },

    _recalculateImageParams() {
      self.stageWidth = isFF(FF_DEV_3377)
        ? self.naturalWidth * self.stageZoom
        : Math.round(self.naturalWidth * self.stageZoom);
      self.stageHeight = isFF(FF_DEV_3377)
        ? self.naturalHeight * self.stageZoom
        : Math.round(self.naturalHeight * self.stageZoom);
    },

    _updateImageSize({ width, height, userResize }) {
      if (self.naturalWidth === undefined) {
        return;
      }
      if (width > 1 && height > 1) {
        const prevWidth = self.canvasSize.width;
        const prevHeight = self.canvasSize.height;
        const prevStageZoom = self.stageZoom;
        const prevZoomScale = self.zoomScale;

        self.containerWidth = width;
        self.containerHeight = height;

        // reinit zoom to calc stageW/H
        self.setZoom(self.currentZoom);

        self._recalculateImageParams();

        const zoomChangeRatio = self.stageZoom / prevStageZoom;
        const scaleChangeRatio = self.zoomScale / prevZoomScale;
        const changeRatio = zoomChangeRatio * scaleChangeRatio;

        self.setZoomPosition(
          self.zoomingPositionX * changeRatio + (self.canvasSize.width / 2 - (prevWidth / 2) * changeRatio),
          self.zoomingPositionY * changeRatio + (self.canvasSize.height / 2 - (prevHeight / 2) * changeRatio),
        );
      }

      self.sizeUpdated = true;
      self._updateRegionsSizes({
        width: self.stageWidth,
        height: self.stageHeight,
        naturalWidth: self.naturalWidth,
        naturalHeight: self.naturalHeight,
        userResize,
      });
    },

    _updateRegionsSizes({ width, height, naturalWidth, naturalHeight, userResize }) {
      const _historyLength = self.annotation?.history?.history?.length;

      self.annotation.history.freeze();

      self.regions.forEach((shape) => {
        shape.updateImageSize?.(width / naturalWidth, height / naturalHeight, width, height, userResize);
      });
      self.regs.forEach((shape) => {
        shape.updateImageSize?.(width / naturalWidth, height / naturalHeight, width, height, userResize);
      });
      self.drawingRegion?.updateImageSize(width / naturalWidth, height / naturalHeight, width, height, userResize);

      setTimeout(self.annotation.history.unfreeze, 0);

      //sometimes when user zoomed in, annotation was creating a new history. This fix that in case the user has nothing in the history yet
      if (_historyLength <= 1) {
        // Don't force unselection of regions during the updateObjects callback from history reinit
        setTimeout(() => self.annotation?.reinitHistory(false), 0);
      }
    },

    updateImageSize(ev) {
      const { naturalWidth, naturalHeight } = self.imageRef ?? ev.target;
      const { offsetWidth, offsetHeight } = self.containerRef;

      self.naturalWidth = naturalWidth;
      self.naturalHeight = naturalHeight;

      self._updateImageSize({ width: offsetWidth, height: offsetHeight });
      // after regions' sizes adjustment we have to reset all saved history changes
      // mobx do some batch update here, so we have to reset it asynchronously
      // this happens only after initial load, so it's safe
      self.setReady(true);

      if (self.defaultzoom === "fit") {
        self.sizeToFit();
      } else {
        self.sizeToAuto();
      }
      // Don't force unselection of regions during the updateObjects callback from history reinit
      setTimeout(() => self.annotation?.reinitHistory(false), 0);
    },

    checkLabels() {
      // there should be at least one available label or none of them should be selected
      const labelStates = self.activeStates() || [];
      const selectedStates = self.getAvailableStates();

      return selectedStates.length !== 0 || labelStates.length === 0;
    },

    addShape(shape) {
      self.regions.push(shape);
      self.annotation.addRegion(shape);
      self.setSelected(shape.id);
      shape.selectRegion();
    },

    /**
     * Resize of image canvas
     * @param {*} width
     * @param {*} height
     */
    onResize(width, height, userResize) {
      self._updateImageSize({ width, height, userResize });
    },

    event(name, ev, screenX, screenY) {
      const [canvasX, canvasY] = self.fixZoomedCoords([screenX, screenY]);

      const x = self.canvasToInternalX(canvasX);
      const y = self.canvasToInternalY(canvasY);

      self.getToolsManager().event(name, ev.evt || ev, x, y, canvasX, canvasY);
    },
  }));

const CoordsCalculations = types
  .model()
  .actions((self) => ({
    // convert screen coords to image coords considering zoom
    fixZoomedCoords([x, y]) {
      if (!self.stageRef) {
        return [x, y];
      }

      // good official way, but maybe a bit slower and with repeating cloning
      const p = self.stageRef.getAbsoluteTransform().copy().invert().point({ x, y });

      return [p.x, p.y];
    },

    // convert image coords to screen coords considering zoom
    zoomOriginalCoords([x, y]) {
      const p = self.stageRef.getAbsoluteTransform().point({ x, y });

      return [p.x, p.y];
    },

    /**
     * @typedef {number[]|{ x: number, y: number }} Point
     */

    /**
     * @callback PointFn
     * @param {Point} point
     * @returns Point
     */

    /**
     * Wrap point operations to convert zoomed coords from screen to image and back
     * Good for event handlers, receiving screen coords, but working with image coords
     * Accepts both [x, y] and {x, y} points; preserves this format
     * @param {PointFn} fn wrapped function do some math with image coords
     * @return {PointFn} outer function do some math with screen coords
     */
    fixForZoom(fn) {
      return (p) => this.fixForZoomWrapper(p, fn);
    },
    fixForZoomWrapper(p, fn) {
      const asArray = p.x === undefined;
      const [x, y] = self.fixZoomedCoords(asArray ? p : [p.x, p.y]);
      const modified = fn(asArray ? [x, y] : { x, y });
      const zoomed = self.zoomOriginalCoords(asArray ? modified : [modified.x, modified.y]);

      return asArray ? zoomed : { x: zoomed[0], y: zoomed[1] };
    },
  }))
  // putting this transforms to views forces other getters to be recalculated on resize
  .views((self) => ({
    // helps to calculate rotation because internal coords are square and real one usually aren't
    get whRatio() {
      // don't need this for absolute coords
      if (!isFF(FF_DEV_3793)) return 1;

      return self.stageWidth / self.stageHeight;
    },

    // @todo scale?
    canvasToInternalX(n) {
      return (n / self.stageWidth) * RELATIVE_STAGE_WIDTH;
    },

    canvasToInternalY(n) {
      return (n / self.stageHeight) * RELATIVE_STAGE_HEIGHT;
    },

    internalToCanvasX(n) {
      return (n / RELATIVE_STAGE_WIDTH) * self.stageWidth;
    },

    internalToCanvasY(n) {
      return (n / RELATIVE_STAGE_HEIGHT) * self.stageHeight;
    },

    internalToImageX(n) {
      const { naturalWidth } = self.currentImageEntity;
      return (n / RELATIVE_STAGE_WIDTH) * naturalWidth;
    },

    internalToImageY(n) {
      const { naturalHeight } = self.currentImageEntity;
      return (n / RELATIVE_STAGE_HEIGHT) * naturalHeight;
    },

    imageToInternalX(n) {
      const { naturalWidth } = self.currentImageEntity;
      return (n / naturalWidth) * RELATIVE_STAGE_WIDTH;
    },

    imageToInternalY(n) {
      const { naturalHeight } = self.currentImageEntity;
      return (n / naturalHeight) * RELATIVE_STAGE_HEIGHT;
    },
  }));

// mock coords calculations to transparently pass coords with FF 3793 off
const AbsoluteCoordsCalculations = CoordsCalculations.views(() => ({
  canvasToInternalX(n) {
    return n;
  },
  canvasToInternalY(n) {
    return n;
  },
  internalToCanvasX(n) {
    return n;
  },
  internalToCanvasY(n) {
    return n;
  },
}));

const ImageModel = types.compose(
  "ImageModel",
  TagAttrs,
  ObjectBase,
  ...(isFF(FF_LSDV_4583) ? [MultiItemObjectBase] : []),
  AnnotationMixin,
  IsReadyWithDepsMixin,
  ImageEntityMixin,
  Model,
  isFF(FF_DEV_3793) ? CoordsCalculations : AbsoluteCoordsCalculations,
);

const HtxImage = inject("store")(ImageView);

Registry.addTag("image", ImageModel, HtxImage);
Registry.addObjectType(ImageModel);

export { ImageModel, HtxImage };
